原始链接

【3W字】💡静态链接和静态库实践指北 - 知乎

0x00 前言

近期的搬砖日常中涉及到了一些对于静态库和动态库的处理,在这个过程中刚好需要捋一捋自己对于C++静态链接和静态库的理解,正好有段时间没写东西了,这次记录下自己的困惑以及思考,方便以后遇到类似的问题可以快速查找;还是那句话,写,既是一种输出,也是一种输入。2023年第一篇,好好写写,希望自己和各位读者都能有收获。关于静态链接和静态库,可能你和我一样,感觉既熟悉又陌生,那么看完这篇文章后,相信你至少在应用层面,对于静态链接和静态库的理解会更清晰些。

0x01 我想弄明白些什么

本文假设读者对基本的编译、链接的过程有基本的认知,并且至少有在工作中使用C/C++的经验,因此对于一些基本的概念不再重复赘述,只重点讲一些本人比较关心的问题: 静态链接和静态库。希望能帮助自己以及正在看这篇文章的读者尽可能地理清楚以下几个问题:

0x02 故事的起点

对知识点进行重复且冗长的陈述,总是容易让人觉得昏昏欲睡。所以在这里,我打算从一个具体的案例开始来逐个解答以上的几个问题。首先,我们先来搭一个基本能覆盖这些问题的简单案例。cmake是我们在工作中很常用的工具,所以,这个简要的案例也将基于cmake进行搭建。整体的目录结构如下所示:

tree .
.
├── CMakeLists.txt
├── liba
│   ├── funca.cc
│   ├── funca.h
│   ├── func.cc
│   └── func.h
├── libb
│   ├── funcb.cc
│   ├── funcb.h
│   ├── func.cc
│   └── func.h
├── libc
│   ├── funcc.cc
│   └── funcc.h
└── test.cc

这里有liba、libb、libc三个目录,每个目录中都有1-2个源文件和头文件,这些源文件的内容很简单,都只包含了一个求和的函数。最外层还有一个测试代码test.cc用来测试这些求和函数。各个源文件中的代码如下所示:

#include "liba/func.h"
int AddFuncA(int a, int b) {return a + b;}
#include "liba/funca.h"
int AddFuncAV2(int a, int b) {return a + b;}
#include "libb/func.h"
float AddFuncB(float a, float b) {return a + b;}
#include "libb/funcb.h"
float AddFuncBV2(float a, float b) {return a + b;}
#include "libc/funcc.h"
#include "liba/func.h"
int AddFuncC(int a, int b) {return AddFuncA(a, a) + AddFuncA(b, b);}

代码的安排暂且这样,后续需要添加的,在讲到对应的问题时再按需添加。接下来是写个简单的CMakeLists.txt来覆盖几个主要的问题,CMakeLists.txt文件的编写是这样的:

PROJECT(staticlib_demo C CXX)
CMAKE_MINIMUM_REQUIRED(VERSION 3.12)
include_directories(${PROJECT_SOURCE_DIR})
set(LIB_ADD_SRCS
        ${PROJECT_SOURCE_DIR}/liba/func.cc
        ${PROJECT_SOURCE_DIR}/liba/funca.cc
        ${PROJECT_SOURCE_DIR}/libb/func.cc
        ${PROJECT_SOURCE_DIR}/libb/funcb.cc)
set(LIB_OTHER_ADD_SRCS
        ${PROJECT_SOURCE_DIR}/libc/funcc.cc)
add_library(addfunc_static STATIC ${LIB_ADD_SRCS}) # 编译静态库
add_library(addfunc_shared SHARED ${LIB_ADD_SRCS}) # 编译动态库
add_library(other_addfunc_static_link_static STATIC ${LIB_OTHER_ADD_SRCS})
add_library(other_addfunc_static_link_shared STATIC ${LIB_OTHER_ADD_SRCS})
add_library(other_addfunc_shared_link_static SHARED ${LIB_OTHER_ADD_SRCS})
add_library(other_addfunc_shared_link_shared SHARED ${LIB_OTHER_ADD_SRCS})
target_link_libraries(other_addfunc_static_link_static addfunc_static) # 编译静态库链接静态库
target_link_libraries(other_addfunc_static_link_shared addfunc_shared) # 编译静态库链接动态库
target_link_libraries(other_addfunc_shared_link_static addfunc_static) # 编译动态库链接静态库
target_link_libraries(other_addfunc_shared_link_shared addfunc_shared) # 编译动态库链接动态库
add_executable(test_static ${PROJECT_SOURCE_DIR}/test.cc)
add_executable(test_shared ${PROJECT_SOURCE_DIR}/test.cc)
target_link_libraries(test_static addfunc_static)
target_link_libraries(test_shared addfunc_shared)

编译运行:

mkdir build && cd build && cmake .. && make -j
# 输出信息如下
[ 40%] Building CXX object CMakeFiles/addfunc_static.dir/liba/funca.cc.o
[ 45%] Building CXX object CMakeFiles/addfunc_static.dir/liba/func.cc.o
[ 50%] Linking CXX static library libother_addfunc_static_link_shared.a
[ 54%] Linking CXX static library libother_addfunc_static_link_static.a
[ 63%] Linking CXX shared library libaddfunc_shared.so
[ 63%] Linking CXX static library libaddfunc_static.a
...
[ 68%] Building CXX object CMakeFiles/test_static.dir/test.cc.o
[ 72%] Building CXX object CMakeFiles/other_addfunc_shared_link_static.dir/libc/funcc.cc.o
[ 77%] Linking CXX shared library libother_addfunc_shared_link_static.so
...
[ 90%] Linking CXX shared library libother_addfunc_shared_link_shared.so
[ 95%] Linking CXX executable test_static
[100%] Linking CXX executable test_shared

这个案例的代码可以从github下载

git clone https://github.com/DefTruth/simd-notebook.git
cd cpp/how_to_build_cpp_static_lib/test_staticlib
mkdir build && cd build
cmake ..
make -j

0x03 问题1:什么是链接,链接做了什么?如何判断编译后的产物经过了“有效”的链接?

要回答这个问题,首先,我们得知道链接做了什么,以及知道如何判断编译的过程中是否发生了有效的链接。为了弄清楚这个问题,我最近又把《程序员的自我修养——链接、装载与库》中关于链接的章节读了一遍,不得不说,写的真好,也推荐大家读一读。这部分内容本身就非常庞大,已经不是一篇文章能说清楚的了,这里我只摘录一些基本的点。(以linux x86_64系统下为例)

那么,什么是链接,链接到底做了什么呢?

《程序员的自我修养——链接、装载与库》中有说到Linux下的ELF文件类型包含 可重定向文件(这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,目标文件.o和静态库.a都可以归为这一类),可执行文件以及共享目标文件(即动态库)。我们在编译一个可执行文件的过程中,首先会得到一些.o文件,比如本文案例中的test.cc.o、liba/func.cc.o等,这些.o文件都是属于可重定向文件,它们彼此之间是相互独立的。比如test.cc.o中虽然引用了AddFuncA()函数,但在.o文件中,这个AddFuncA()只是一个未定义的外部符号,test.cc.o并不知道AddFuncA()实际的定义在哪。这时,为了得到一个能够可以运行的可执行文件,就需要链接器ld来把这些独立的.o文件都组装起来。这个组装的过程就是“链接”,在链接的过程中,ld链接器首先会对目标文件.o进行相似段合并,然后为各个段以及符号分配空间地址、进行符号解析与重定向。

接下来,我们如何判断编译的过程中是否发生了 有效的链接的呢?(用objdump分析)

从对链接的描述中,我们可以提取出核心的点:链接会对 可重定向的目标文件.o进行相似段合并,然后为各个段以及符号分配空间地址、进行符号解析与重定向。进一步地,我们可以拆解成以下2点:

可重定向文件的特点:可重定向,包含 .rel.text、.rel.data等重定位表,并且VMA(虚拟地址)和LMA(加载地址)未知,即均为0(0x0000000000000000);

**链接之后的改变:**得到的可执行文件或共享库已经被重定向过了,不再需要重定向表,因此 .rel.text、.rel.data不再存在;同时,VMA和LMA已经被具体赋值,不再是0.

因此,基于以上的观点,我们可以通过objdump工具,来对链接前后的文件进行分析,从而来判断链接是否被“有效”地执行了。这里简要说明objdump 的一些用法:

-h, -r, -j 在本文中主要的作用是,用来判断链接前后动态库或静态库或可执行文件中的VMA虚拟地址信息以及是否包含重定向表。首先,我们来查看一下静态库中的目标文件的VMA和LMA。

objdump -h -j .text libaddfunc_static.a
# 输出信息如下  
func.cc.o:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000014  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

funca.cc.o:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000014  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

可以发现,静态库中每个.o文件的.text代码段的VMA和LMA都是0x0,这说明静态库实际上是尚未参与ld链接的。静态库是一堆可重定向的目标文件.o的集合,它实际的状态是待链接的。再来看下静态库的重定向信息表。

objdump -r libaddfunc_static.a
# 输出信息如下
func.cc.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE
0000000000000020 R_X86_64_PC32     .text
# ...

由于示例比较简单,所以这里的重定向表也比较简单。并且静态库的各个.o之间没有引用关系,所以这里也没出现.text的重定向表。我们可以直接看下 test.cc 生成的test.o,它引用了静态库中的符号。

objdump -r CMakeFiles/test_static.dir/test.cc.o
# 输出信息如下
CMakeFiles/test_static.dir/test.cc.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:  # .text代码段的重定向信息表  
OFFSET           TYPE              VALUE
000000000000000a R_X86_64_32       .rodata+0x0000000000000001
000000000000000f R_X86_64_32       _ZSt4cout
0000000000000014 R_X86_64_PLT32    _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc-0x0000000000000004
0000000000000026 R_X86_64_PLT32    _Z8AddFuncAii-0x0000000000000004  # 对 AddFuncA 的引用
# ...
0000000000000064 R_X86_64_PLT32    _Z8AddFuncBff-0x0000000000000004 # 对 AddFuncB 的引用
000000000000006c R_X86_64_PLT32    _ZNSolsEf-0x0000000000000004
# ...

可以看到由于还没有进行链接,test.cc.o中保留了必要的重定向信息表,比如 “_Z8AddFuncAii”就是对 AddFuncA 的引用,“_Z8AddFuncBff”就是对AddFuncB 的引用。这个奇怪的符号名,是遵循C++的函数签名规则映射得到的,大家感兴趣的可以自己去查查具体的规则,这里也不展开了。我们再看看链接后的test_static可执行文件是否还存在重定向表。

# 查看重定向表
objdump -r test_static
# 输出信息如下
test_static:     file format elf64-x86-64 # 只有文件头了 没有 .rel.text、.rel.data
# 查看.text代码段的VMA和LMA
objdump -h -j .text test_static
# 输出信息如下
test_static:     file format elf64-x86-64
Sections:     # VMA和LMA已被赋予具体的值 并且按照16字节进行对齐
Idx Name          Size      VMA               LMA               File off  Algn
 15 .text         000002b5  0000000000001100  0000000000001100  00001100  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

此时发现真正链接过后的可执行文件,已经不包含可重定向表了,并且VMA和LMA都已经被赋予了具体的值,首地址也按照16字节进行了地址对齐。同理,我们来看下动态库的情况。 可以发现,真正发生ld链接过后,动态库和可执行文件一样,都不存在重定向表,并且VMA虚拟地址已经被赋予具体的值。

objdump -r libaddfunc_shared.so
# 输出信息如下
libaddfunc_shared.so:     file format elf64-x86-64 # 只有文件头了 没有 .rel.text、.rel.data
objdump -h -j .text libaddfunc_shared.so
# 输出信息如下
libaddfunc_shared.so:     file format elf64-x86-64
Sections:     # VMA和LMA已被赋予具体的值 并且按照16字节进行对齐
Idx Name          Size      VMA               LMA               File off  Algn
 10 .text         00000111  0000000000001040  0000000000001040  00001040  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

注意,以上的分析是从 **“链接视图”**的角度来分析,另一种分析方式是从 **“装载视图”**的角度来分析(可以使用readelf工具)。完整的链接和装载的内容是一个非常复杂且庞大的内容,已经不是本文能覆盖的了,因此,我只能选择其中一小部分内容来描述。回到这个“链接视图”,针对动态库和可执行文件,有些内容需要补充,以免对读者造成误导。

首先,关于动态库和可执行文件中的VMA的值的含义。对于动态库,按照共享库的特点,“共享对象在编译时不能假设自己在进程中虚拟地址空间中的位置”(摘自《程序员的自我修养》7.3.1小节-动态链接-固定装载地址的困扰)。因此对于共享库而言,这个地址是相对于库文件“起点”的偏移量,我们可以看到objdump输出的动态库libaddfunc_shared.so中.text代码段的VMA为0x0000000000001040,而它在库文件中的File offset也刚好是0x00001040,因此这个VMA并不是指真正意义上的虚拟内存中的有效地址。对于共享库,真正的链接(重定向)发生在“装载”时,而非在编译时,这个链接由动态链接器ld-xxx.so完成(ld实际上是静态链接器,即在编译时进行重定向)。

而关于可执行文件,则有点特别,按照《程序员的自我修养》中的说明,“可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定的空闲地址”,比如0x08040000或0x0040000。但是你看刚刚objdump出来的结果,发现是却并不符合这个描述的。(我是在unbutu 20.04, gcc 9的环境下编译的)

objdump -h -j .text test_static
# 输出信息如下
test_static:     file format elf64-x86-64
Sections:     # VMA和LMA已被赋予具体的值 并且按照16字节进行对齐
Idx Name          Size      VMA               LMA               File off  Algn
 15 .text         000002b5  0000000000001100  0000000000001100  00001100  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

这里的VMA的值和File offset一样,都是0x00001100,它依然是个相对偏移量啊,并不是虚拟内存中的有效地址。这是为什么呢?我猜测是和我用了高版本的gcc有关系吧,编译器的某些行为发生了改变?这可以通过readelf进行验证。

readelf -l test_static
# 输出信息如下
Elf file type is DYN (Shared object file)
Entry point 0x1100
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000

这时的可执行文件和共享库是同一个文件格式,即 DYN (Shared object file)。应该是可执行文件test_static虽然是静态链接了libaddfunc_static.a,但是对于系统库libc,它依然采用的是 动态链接的方式。用ldd验证下即可:

ldd test_static
        linux-vdso.so.1 (0x00007fff72f3b000)
        libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f9fe85e9000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9fe83f7000)  # 依赖了很多系统.so
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f9fe82a8000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f9fe87da000)  # 动态链接器 ld-xxx.so 处理运行时重定向
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f9fe828d000)

可以看到,这个可执行文件依赖了 /lib64/ld-linux-x86-64.so.2,这东西就是动态链接器!(提示:没错,动态链接器也是一个共享库!而/usr/bin/ld是我们最常接触到链接器,它实际上是静态链接器!动态链接器负责在装载时重定向,静态链接器则是在编译时重定向,当然静态链接器还有合并相似度以及地址分配等许多基本的功能),因此这个可执行文件采用的应该是装载时重定向的方式。我猜测,由于这个原因,这个可执行文件被gcc保存成了DYN (Shared object file),因此我们通过objdump看到的这个可执行文件的VMA是个相对偏移量,和动态库中的表现一致。那么,问题来了,是不是如果连系统库都是静态链接进test_static后,VMA就是虚拟内存地址了呢?马上来试一试。

g++ -static test.cc -o test -L./build/ -laddfunc_static  # 静态链接系统库 -static

分别用objdump和readelf检查一下

objdump -h -j .text test
# 输出信息如下
test:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  6 .text         0016f8db  0000000000401230  0000000000401230  00001230  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

readelf -l test
# 输出信息如下
Elf file type is EXEC (Executable file)
Entry point 0x404ad0
There are 10 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000005f0 0x00000000000005f0  R      0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x000000000017180d 0x000000000017180d  R E    0x1000
  LOAD           0x0000000000173000 0x0000000000573000 0x0000000000573000

ldd test
# 输出信息如下
        not a dynamic executable

答案出来了!首先,objdump的结果显示,VMA为0x0000000000401230,而File offset为0x00001230,这两个值是不一样的!VMA的值即为程序的起始地址0x000000000040000 + 0x0000000000001230,是一个绝对虚拟内存地址,而不再是相对偏移量!而且,readelf的结果显示,此时的文件格式为:“ EXEC (Executable file)”,原来依赖了动态系统库的可执行文件的文件格式则为:“ DYN (Shared object file)”。

最后,关于 .rel.text等重定向表,动态库和可执行文件不包含.rel.text、.rel.data(这些是目标文件中包含的),但依然会包含用于装载时重定向的.rel.dyn、plt、got等表段(与动态链接的装载时重定向原理相关,不展开来说了),可以用objdump -R (大写R)、objdump -h查看或者 readelf -l,如

# 动态库
objdump -R libaddfunc_shared.so

libaddfunc_shared.so:     file format elf64-x86-64

DYNAMIC RELOCATION RECORDS  # 动态库的重定向表
OFFSET           TYPE              VALUE
0000000000003e70 R_X86_64_RELATIVE  *ABS*+0x00000000000010f0
0000000000003e78 R_X86_64_RELATIVE  *ABS*+0x00000000000010b0
0000000000004018 R_X86_64_RELATIVE  *ABS*+0x0000000000004018
0000000000003fe0 R_X86_64_GLOB_DAT  __cxa_finalize
0000000000003fe8 R_X86_64_GLOB_DAT  _ITM_registerTMCloneTable
0000000000003ff0 R_X86_64_GLOB_DAT  _ITM_deregisterTMCloneTable
0000000000003ff8 R_X86_64_GLOB_DAT  __gmon_start__

# 可执行文件
objdump -R test_shared

test_shared:     file format elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE
# ...
0000000000003fc8 R_X86_64_GLOB_DAT  __cxa_finalize@GLIBC_2.2.5
0000000000003fd0 R_X86_64_GLOB_DAT  _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@GLIBCXX_3.4
0000000000003fd8 R_X86_64_GLOB_DAT  _ITM_deregisterTMCloneTable
0000000000003fe0 R_X86_64_GLOB_DAT  __libc_start_main@GLIBC_2.2.5
0000000000003fe8 R_X86_64_GLOB_DAT  __gmon_start__
0000000000003ff0 R_X86_64_GLOB_DAT  _ITM_registerTMCloneTable
0000000000003ff8 R_X86_64_GLOB_DAT  _ZNSt8ios_base4InitD1Ev@GLIBCXX_3.4
0000000000004040 R_X86_64_COPY     _ZSt4cout@@GLIBCXX_3.4
0000000000003f88 R_X86_64_JUMP_SLOT  _Z8AddFuncAii
0000000000003f90 R_X86_64_JUMP_SLOT  _ZNSolsEf@GLIBCXX_3.4
0000000000003f98 R_X86_64_JUMP_SLOT  __cxa_atexit@GLIBC_2.2.5
0000000000003fa0 R_X86_64_JUMP_SLOT  _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@GLIBCXX_3.4
0000000000003fa8 R_X86_64_JUMP_SLOT  _ZNSolsEPFRSoS_E@GLIBCXX_3.4
0000000000003fb0 R_X86_64_JUMP_SLOT  _Z8AddFuncBff
0000000000003fb8 R_X86_64_JUMP_SLOT  _ZNSt8ios_base4InitC1Ev@GLIBCXX_3.4
0000000000003fc0 R_X86_64_JUMP_SLOT  _ZNSolsEi@GLIBCXX_3.4

目前为止,涉及的东西已经越来越多了,对于本文来说,了解到这里就差不多了,更多的知识大家可以自行查阅相关资料。最后,我们用一个表格来总结下这个问题:

文件格式 归属阶段 文件特点 判断方式
静态库 待链接,可重定向文件.o的集合。 .a文件中的每个目标文件.o均包含 .rel.text, .rel.data等重定向表,并且各个段的VMA和LMA为0x0 objdump -r libxxx.a
objdump -h -j .text libxxx.a
动态库 链接之后生成,已进行相似段合并和地址相对偏移量计算,真正的重定向发生在装载时而非编译时 不再包含.rel.text和.rel.data等重定向表,VMA和LMA均不为0x0,这个地址是相对于共享库本身而言的偏移量;但会包含.rel.dyn、got、plt等动态库在运行时被重定向所需的重定向表 objdump -r libxxx.so
objdump -h -j .text libxxx.so
readelf -l libxxx.so
objdump -R ibxxx.so
可执行文件 可能(1):链接之后生成,已进行相似段合并和地址相对偏移量计算,真正的重定向发生在装载时而非编译时;可能(2):所有的依赖库均为静态链接,则链接之后生成的可执行文件,已进行相似段合并和地址重定向,VMA的值是绝对虚拟内存地址 如果动态链接libc.so,则本质上输出的文件格式为DYN (Shared object file),基本文件件性质和动态库的so类似,包含.rel.dyn、got、plt等在运行时被重定向所需的重定向表,并且依赖了动态链接器ld-xxx.so;如果是通过-static选项编译生成,并且不依赖任何动态库,则生成的文件格式为EXEC (Executable file),此时不再包含重定向表 .rel.dyn、.rel.text和.rel.data,VMA和LMA均不为0x0,此时这个地址代表的是被分配到的可用虚拟内存中的绝对地址 objdump -r xxx_exe
objdump -h -j .text xxx_exe
readelf -l xxx_exe
ldd xxx_exe
objdump -R xxx_exe

呼~ 先长呼一口气,为了回答问题1,已经花了不少篇幅,但是在这个问题很重要,它是我们理解其他问题的基础。

0x04 问题2:编译静态库和编译动态库的区别是什么?这些区别在cmake中是如何体现的?

其实问题1的回答中,已经包含了问题2中的部分答案,但是我们还是结合cmake来进一步分析这个问题。这样更贴合我们在工作中的实际应用。首先,来讲讲我的困惑。我们经常能在cmake .. && make -j之后看到类似这样的提示:

[ 63%] Linking CXX shared library libaddfunc_shared.so
[ 63%] Linking CXX static library libaddfunc_static.a

它正在链接一个静态库和一个动态库,这个提示看起来是一样的,但实际上链接生成一个静态库和链接生成一个动态库, 它们真实发生的链接过程真的是一样的吗?(答案: **不一样的!!!**想知道答案的话就继续往下看吧)

“link.txt”?这又是个什么东西?如果你没见过这个文件,那一定会产生这样的疑问。"link.txt"是我们使用cmake管理工程,在 cmake .. && make -j 后的附带产物。这个文件通常会被我们忽略掉,但它其实是对于我们理解cmake之后编译的过程最后阶段的"Linking xxx ..."是非常有帮助的,它描述了这个阶段所真实发生的事情。仔细查看cmake后生成的产物,会发现,我们利用cmake进行工程管理的时候,cmake会生成一个名为CMakeFiles的目录,并且在该目录下生成了各个子工程相关的信息,各个子工程的文件一般保存在 xxx.dir 文件夹内。

ls CMakeFiles/ | grep "dir"
addfunc_shared.dir/
addfunc_static.dir/
test_shared.dir/
test_static.dir/

重点来了,每个这样的dir内,都会有一个名为"link.txt"的文件,这正是我们所需要的,它描述了真正发生的链接过程是怎样的。

find . -name "link.txt"
./CMakeFiles/addfunc_static.dir/link.txt
./CMakeFiles/addfunc_shared.dir/link.txt
./CMakeFiles/test_static.dir/link.txt
./CMakeFiles/test_shared.dir/link.txt
# ...

这对于我们去分析这个"Linking xxx ..."的过程中真正发生了什么是很有帮助的,我们直接来看下吧。(其实可执行文件和动态库的链接过程比较相似,我们也一块看了吧)

# 编译一个静态库时的“链接”
cat ./CMakeFiles/addfunc_static.dir/link.txt
# 输出信息为
/usr/bin/ar qc libaddfunc_static.a  CMakeFiles/addfunc_static.dir/liba/func.cc.o CMakeFiles/addfunc_static.dir/liba/funca.cc.o CMakeFiles/addfunc_static.dir/libb/func.cc.o CMakeFiles/addfunc_static.dir/libb/funcb.cc.o
/usr/bin/ranlib libaddfunc_static.a

发现了没!!!用的是 ar直接创建的静态库,把.o文件打包在一起形成一个.a文件,实际上并 没有调用ld链接器(划重点:链接生成一个静态库用的是ar,而不是ld链接器),也就是说,编译一个静态库,cmake其实只是将.o文件合并了而已,并没有发生真正意义上的“链接”,准确来说,编译一个静态库的过程,虽然cmake的提示是"Linking xxx.a ....",但理解为"Merging xxx.a ..."更合适些。事不宜迟,我们接着来看动态库和可执行文件的链接过程。

# 编译一个动态库时的链接
cat ./CMakeFiles/addfunc_shared.dir/link.txt
# 输出信息为
/usr/bin/c++ -fPIC   -shared -Wl,-soname,libaddfunc_shared.so -o libaddfunc_shared.so CMakeFiles/addfunc_shared.dir/liba/func.cc.o CMakeFiles/addfunc_shared.dir/liba/funca.cc.o CMakeFiles/addfunc_shared.dir/libb/func.cc.o CMakeFiles/addfunc_shared.dir/libb/funcb.cc.o

# 编译可执行文件 并链接一个动态库
cat ./CMakeFiles/test_shared.dir/link.txt
# 输出信息为
/usr/bin/c++     CMakeFiles/test_shared.dir/test.cc.o  -o test_shared  -Wl,-rpath,/home/tmp/test_staticlib/build libaddfunc_shared.so

# 编译可执行文件 并链接一个静态库
cat ./CMakeFiles/test_static.dir/link.txt
# 输出信息为
/usr/bin/c++     CMakeFiles/test_static.dir/test.cc.o  -o test_static  libaddfunc_static.a

可以发现,动态库和可执行文件的链接过程都调用了 **/usr/bin/c++ (封装了ld链接器)**来进行真正意义上的链接,进行了相似段合并、地址分配以及符号重定向(提示:对于动态库或者链接了动态库的可执行文件而言,这里的重定向仅仅做了地址偏移量计算,不会执行真正的重定向,对于动态库真正的重定向发生在装载时由ld-xxx.so动态链接器完成,而非在编译时)等操作,通过之前的objdump分析,也表明可执行文件和动态库中不存在重定向表.rel.text和.rel.data并且对每个段和符号都分配了VMA和LMA。

所以,从上面这段分析,我们发现了一个重要的事情就是: 存在虚假的链接(编译静态库)和有效的链接(提示:对于编译动态库和可执行文件,虽然有2/3的情况下,这个编译时的链接并不执行真正的重定向,而是仅仅分配了相对地址,但我们也暂时认为是“有效”的吧,不然描述起来就非常绕了)。编译一个静态库和编译一个动态库,最大的区别就是,编译静态库时,实际上并不会发生真正的“链接”,它只是将目标文件.o合并成一个.a库了而已,既没有发生相似段合并,也没有发生符号重定向;而编译一个动态库,是发生了“有效”的链接过程的,需要经过相似段合并和地址分配,在相似段合并的过程中,会将必须要用到的.o合并在一起(从这个角度理解,编译动态库,并且该库链接了一个静态库,实际上是链接了很多.o文件而已,然后ld链接器把这些.o也合并进来,就产生了所谓的编译完这个动态库,就不需要那个被链接的静态库的情况了;然而编译静态库时,则实际上没有发生这个链接过程)。

用一个表格来总结下这个问题:

执行的行为 cmake所执行的"链接" 特点描述
编译静态库 使用ar,对.o文件进行合并 虚假的链接,只是使用ar合并了.o文件
编译动态库 使用ld链接器,执行相似段合并、地址偏移量计算 发生了“有效”的链接,但没有做真正意义的重定向。
编译可执行文件 使用ld链接器,执行相似段合并、地址偏移量计算(也有可能发生了真正的重定向) 发生了“有效”的链接。可能做了重定向也可能没有,取决于是否有动态库依赖。
提示:对于动态库和依赖的动态库的可执行文件,ld链接器仅仅做了地址偏移量计算,因此也不是真正的重定向,它们真正的重定向发生在装载时,这个过程由ld-xxx.so动态链接器完成,而非在编译时;

0x05 问题3:静态链接和编译静态库是同一个概念吗?它们有区别吗?

梳理了问题1和问题2之后,问题3就很好回答了。答案:不是同一个概念。很明显,它们不是同一个概念,"静态链接"强调的指的是,编译可执行文件或动态库时,去链接一个静态库.a,其实相当于链接了一堆已经被编译好的.o,然后使用ld执行执行相似段合并、地址分配和重定向,对于编译可执行文件和动态库而言,“静态链接”一定发生了真正的链接。但是对于编译静态库而言,“静态链接”只是个幌子。而编译静态库,本质上只是对.o文件的打包合并,可以认为它和真正的链接过程并没有什么关系。

**0x06 问题4:**两个同名目标文件.o在ar创建静态库后,会不会有一个被覆盖?

之所以提出这个问题,是因为,在实际开发的过程中,确实很容易出现不同的目录下有同名源文件的情况,不如不同的文件目录下都有个 http:// utils.cc 啥的,弄清楚在ar合并.o为静态库.a时,到底是怎么处理同名目标文件是一件有必要的事情。

我们可以通过手动执行ar命令来验证这个问题。在liba和libb中,存在同名的源文件 http:// func.cc ,编译之后都叫做func.cc.o,我们通过ar将包括这两个同名.o文件在内的所有目标文件合并成一个.a静态库。

# 创建静态库 s表示创建索引表 其实现在的ar默认会添加s
ar -qcs libaddfunc_static.a \
  CMakeFiles/addfunc_static.dir/liba/func.o \
  CMakeFiles/addfunc_static.dir/liba/funca.o \
  CMakeFiles/addfunc_static.dir/libb/func.o \
  CMakeFiles/addfunc_static.dir/libb/funcb.o
# 不放心的话 可以再调用 ranlib 更新静态库符号表索引  
ranlib libaddfunc_static.a  
# cmake编译静态库时一般就是 ar -qc + ranlib 两个步骤,这是为了兼容老版本的ar
# 较老版本的 ar 还没有 -s 参数
# 查看静态库中包含的目标文件 *.o  
ar -tvO libaddfunc_static.a # -tv | -tvO | -t 均可
# 输出信息如下  
rw-r--r-- 0/0   1216 Jan  1 00:00 1970 func.cc.o 0xd2
rw-r--r-- 0/0   1224 Jan  1 00:00 1970 funca.cc.o 0x5ce
rw-r--r-- 0/0   1224 Jan  1 00:00 1970 func.cc.o 0xad2
rw-r--r-- 0/0   1232 Jan  1 00:00 1970 funcb.cc.o 0xfd6

可以发现,哪怕在不同的文件目录下存在同名的.o文件,比如liba/func.o和libb/func.o,ar在创建静态库的时候,会将两个同名的.o文件都保存下来。ar具体的实现机制我目前不太清楚,猜测可能是实现了类似索引的功能,虽然是相同的文件名,但ar可能会给他们标记成实际上是不同的文件。在实际开发的过程中,确实很容易出现不同的目录下有同名源文件的情况,不如不同的文件目录下都有个 http:// utils.cc 啥的,但是编译静态库时,我们不用去担心这个问题,因为ar已经帮我们处理好了。

# nm -s 可以查看静态库中的符号表信息  
nm -s libaddfunc_static.a  
# 输出信息如下
Archive index:
_Z8AddFuncAii in func.cc.o
_Z10AddFuncAV2ii in funca.cc.o
_Z8AddFuncBff in func.cc.o
_Z10AddFuncBV2ff in funcb.cc.o
func.cc.o:
0000000000000000 T _Z8AddFuncAii
funca.cc.o:
0000000000000000 T _Z10AddFuncAV2ii
func.cc.o:
0000000000000000 T _Z8AddFuncBff
funcb.cc.o:
0000000000000000 T _Z10AddFuncBV2ff

可以看到,合并后的静态库中保留了两个同名的func.cc.o中各自的函数符号。所以,这个问题的答案就是:同名的.o文件不会被直接覆盖,可以放心使用ar来合成静态库。

**0x07 问题5:**编译动态库可以链接静态库,编译静态库也可以链接静态库,那它们有区别吗?

首先,给出答案: 有区别!在问题1和问题2的基础上,我们可以很好地理解这个问题。区别是:

**动态库链接静态库:**是 **“有效”**的链接,在编译动态库时,虽然ld链接器没有执行真正的重定向,但是它还是执行了“链接”过程中的其他步骤,如相似段合并、地址分配等。而编译一个动态库时,去链接一个静态库,比如编译libA.so链接了libB.a,那么libB.a中的.o文件会被ld链接器遍历查找,把其中libA.so所必须用到的部分.o加入到相似段合并的过程中。

**静态库链接静态库:**是 **"虚假"**的链接。是的,这个所谓的链接就是假的,你又被骗了!用了这么久的gcc和cmake,没想到它们竟然是个糟老头子~ 根据问题1和问题2的分析,编译一个静态库根本就不会发生链接。你可能以为,你在编译一个libA.a,并且它链接了libB.a,在生成libA.a的时候,ld链接器会负责任地为你检查libA.a和libB.a中的符号依赖,于是你信心十足地写下了这段cmake代码。

add_library(A STATIC ${LIB_A_SRCS}) # 编译静态库
target_link_libraries(A B) # 编译静态库A 并链接静态库B

但事实上就是,ld链接器根本就没有出场!这时,ar披着链接的外衣,正对libB.a发出了不屑的嘲笑,要你这个libB.a有何用?ar根本就不会去处理符号间的依赖关系,有没有这个libB.a,结果都一样,哪怕libA.a中确实用了libB.a中的符号。当你写下"target_link_libraries(A B)"的时候,你已经陷入了一个假象。

接下来,我们还是结合cmake来具体分析这个问题。首先,查看“编译动态库并链接静态库”中的"link.txt":

cat CMakeFiles/other_addfunc_shared_link_static.dir/link.txt
# 输出信息如下
/usr/bin/c++ -fPIC   -shared -Wl,-soname,libother_addfunc_shared_link_static.so -o \
libother_addfunc_shared_link_static.so CMakeFiles/other_addfunc_shared_link_static.dir/libc/funcc.cc.o  \
libaddfunc_static.a

我们看到,libaddfunc_static.a这个静态库确实参与到了动态库的生成过程,它被链接进去了,然后和其他.o文件一起生成了最终的动态库 libother_addfunc_shared_link_static.so 。

接下来,我们再来查看“编译静态库并链接静态库”中的"link.txt":

cat CMakeFiles/other_addfunc_static_link_static.dir/link.txt
# 输出信息如下
/usr/bin/ar qc libother_addfunc_static_link_static.a  \
CMakeFiles/other_addfunc_static_link_static.dir/libc/funcc.cc.o
/usr/bin/ranlib libother_addfunc_static_link_static.a  # 没有libaddfunc_static.a !

对比下CMakeLists.txt中写下的链接关系:

set(LIB_OTHER_ADD_SRCS
        ${PROJECT_SOURCE_DIR}/libc/funcc.cc)
add_library(addfunc_static STATIC ${LIB_ADD_SRCS}) # 编译静态库
add_library(other_addfunc_static_link_static STATIC ${LIB_OTHER_ADD_SRCS})
target_link_libraries(other_addfunc_static_link_static addfunc_static) # 编译静态库链接静态库

相信细心的同学已经发现了,libaddfunc_static.a这个静态库并没有参与到ar创建静态库的过程中!是的,它直接被忽略掉了!这该怎么理解呢?我们知道,编译一个静态库根本就不会发生链接,也就是不需要去检查符号间的依赖关系,所以,有没有这个libaddfunc_static.a,都不会影响libother_addfunc_static_link_static.a的生成,cmake采取的策略就是,忽略它!

基于这个观察的结果,我们不妨更大胆一些,做个猜测,去掉这句“target_link_libraries”,libother_addfunc_static_link_static.a 应该一样能正常编译!回顾一下libc/funcc.cc的代码,它确实使用了libaddfunc_static.a中的符号。

#include "libc/funcc.h"
#include "liba/func.h"
int AddFuncC(int a, int b) {return AddFuncA(a, a) + AddFuncA(b, b);}

为了验证这个猜测,我们修改一下CMakelists.txt:

set(LIB_OTHER_ADD_SRCS
        ${PROJECT_SOURCE_DIR}/libc/funcc.cc)
add_library(other_addfunc_static_link_static STATIC ${LIB_OTHER_ADD_SRCS})
# target_link_libraries(other_addfunc_static_link_static addfunc_static) # 注释掉这句

编译结果如下,和我们猜测的一样,它编译传成功了!如果你此时再次查看它的link.txt,会发现和没有注释掉这句"target_link_libraries"之前的link.txt一模一样,没有任何区别。

[ 54%] Linking CXX static library libother_addfunc_static_link_static.a

所以,从这个分析中,我们可以得到一个结论就是:编译静态库并链接静态库,这种操作没有任何实际的意义,大部分情况下是一种自欺欺人的行为。

0x08 问题6:编译静态库可以链接一个动态库吗?这样的静态库能用吗?

承上启下,问题6很好回答,首先回答前半个问题。编译静态库,可以链接一个动态库,但这种链接只是一种假象,没有实际的意义,无论你是否链接这个动态库,都不会影响这个静态库的生成。我们可以直接查看案例中的link.txt来确认这个结论。

cat CMakeFiles/other_addfunc_static_link_shared.dir/link.txt
/usr/bin/ar qc libother_addfunc_static_link_shared.a CMakeFiles/other_addfunc_static_link_shared.dir/libc/funcc.cc.o
/usr/bin/ranlib libother_addfunc_static_link_shared.a

我们在CMakeLists.txt的代码为:

target_link_libraries(other_addfunc_static_link_shared addfunc_shared) # 编译静态库链接动态库

但是,从link.txt中信息来看,libaddfunc_shared.so显然也没有参与到libother_addfunc_static_link_shared.a的生成过程中来。

对于后半个问题:这样的静态库能用吗?答案是:能用。它只是一个正常的静态库而已,并没有更特别的地方。只是需要注意的是,如果这个静态库使用了其他库(动态库或静态库)的符号(比如libother_addfunc_static_link_shared.a中使用了libaddfunc_shared.so中的函数AddFuncA),那么在真正使用的时候,你不仅需要链接这个静态库本身,还需要链接包含了他所引用的符号所在的库。比如:

add_executable(test_exe test_exe.cc);
target_link_libraries(test_exe libother_addfunc_static_link_shared.a);
target_link_libraries(test_exe libaddfunc_shared.so);

如果,这个静态库依赖的库也是静态库,那么还可以手动合并这些静态库为一个整体的静态库.a,在使用时,只需要链接这个这个整体的库即可。如

ar crsT libmerge.a libother_addfunc_static_link_static.a libaddfunc_static.a

在CMakeLists.txt可以这么写:

add_executable(test_exe test_exe.cc);
target_link_libraries(test_exe libmerge.a);

0x09 问题7:为什么我编译的静态库在用的时候总是会出现各种undefined崩溃?

明白了问题5和问题6,对于问题7,相信大家自己就可以推敲出结论。

在实际应用的过程中,经常会出现这么一种情况。我们通过cmake管理并编译了一个libA.a的静态库,这个libA.a可能依赖的另一个libB.a(或libB.so)的库。在使用这个libA.a的时候,可能会发生一个最常见的错误,那就是“符号未定义(undefined)”!这是因为编译libA.a的时候并没有发生真实的链接,他只是把libA.a自己的那部分.o文件打包在一起了,并没有将libB.a中的.o也合并进来(没有真实的链接,也就不存在相似段合并的过程),但我们的libA.a又必须依赖libB.a,它引用了libB.a中的某些符号,于是导致在使用libA.a来编译其他可执行文件或库的时候,出现了各种undefined的问题(比如我之前尝试的编译onnxruntime静态库来用的问题)。

那么怎么解决呢?至少有两种可以行的方案,在问题6已经回答了。分别是在CMakeLists.txt中明确地逐个链接所有需要的库,另一种做法是,将编译得到的libA.a和它所依赖的libB.a合并一个库,然后供第三方使用。

ar crsT libmerge.a libxxx.a libxxxb.a  # 必须要有参数T
ar -tvO libmerge.a # 查看合并后的文件

参数T表示将后续所有静态库中的.o文件打包到第一个参数指定的静态库文件中,如果不加该参数,得到的将会是后面几个.a文件的集合。可以使用命令ar -tv查看打包的内容。

0x0a 问题8:为什么我编译的静态库出现了诡异的全局变量为初始化的情况?

说实话,这个问题8并不是很好回答,因为它并没有那么显而易见,如果不是刚好踩了这个坑,可能我也不会注意到这个问题,并且,编译动态库时可以正常使用,但是编译静态库时却有问题。之前在查找解决方案的时候,发现有些类似的帖子,大家可以参考下:

接下来构造一个例子,来尽可能简单地描述这个问题。首先我们在原来的liba目录下,增加一个 http:// global.cc ,并把它加入到add_func的库中,它只包含一个很简单的代码:

#include <iostream>
class ACls {
public:
 ACls() {std::cout << "Create an ACls instance and do some things!" << std::endl;}
};
// create a global instance.
ACls* a_inst = new ACls(); 

在实际的应用中,我们可能会有一些类似但更复杂的工厂类,在new这个全局变量的时候,我们希望它同时能完成一个必要的初始化工作,比如实现某些op或某些产品编号的注册。这种方式,在动态库的方式下,一般可以正常运行,但不幸的是,如果类似以上这段逻辑,被编译到一个静态库时,就会发生这个“诡异的全局变量”未初始化的问题。我们来简单验证下,首先,修改一下CMakeLists.txt。

set(LIB_ADD_SRCS
        ${PROJECT_SOURCE_DIR}/liba/func.cc
        ${PROJECT_SOURCE_DIR}/liba/funca.cc
        ${PROJECT_SOURCE_DIR}/liba/global.cc  # 加入了global.cc
        ${PROJECT_SOURCE_DIR}/libb/func.cc
        ${PROJECT_SOURCE_DIR}/libb/funcb.cc)
set(LIB_OTHER_ADD_SRCS ${PROJECT_SOURCE_DIR}/libc/funcc.cc)
add_library(addfunc_static STATIC ${LIB_ADD_SRCS}) # 编译静态库 
add_library(addfunc_shared SHARED ${LIB_ADD_SRCS}) # 编译动态库
add_executable(test_static ${PROJECT_SOURCE_DIR}/test.cc) 
add_executable(test_shared ${PROJECT_SOURCE_DIR}/test.cc)
target_link_libraries(test_static addfunc_static) # 使用静态库
target_link_libraries(test_shared addfunc_shared) # 使用动态库

编译之后,得到了 test_static 和 test_shared 这两个可执行文件,他们分别是通过链接了静态的和动态的库add_func库生成的。我们直接执行一下这两个文件,会发现,他们的行为是不一样的。

root@b99fab697c9e:build# ./test_shared
Create an ACls instance and do some things!  # 执行了全局变量ACls* a_inst 的初始化!
AddFuncA(2, 3): 5
AddFuncB(2.0f, 3.0f): 5
root@b99fab697c9e:build# ./test_static
AddFuncA(2, 3): 5  # 没有执行 ACls* a_inst 的初始化!
AddFuncB(2.0f, 3.0f): 5

从结果来看,test_shared 运行后,成功初始化了 ACls* a_inst,这正是我们想要的行为;但是看 test_static,并 没有对全局变量 ACls* a_inst 进行初始化!为什么会这样呢?

我们可以先来看下 a_inst 这个符号在库中以及在可执行文件中的情况。

root@b99fab697c9e:build# objdump -t libaddfunc_shared.so | grep a_inst
0000000000004070 g     O .bss        0000000000000008              a_inst
root@b99fab697c9e:build# objdump -t libaddfunc_static.a | grep a_inst
0000000000000093 l     F .text        0000000000000019 _GLOBAL__sub_I_a_inst
0000000000000000 g     O .bss        0000000000000008 a_inst
root@b99fab697c9e:build# objdump -t test_shared | grep a_inst  # 没有输出
root@b99fab697c9e:build# objdump -t test_static | grep a_inst  # 没有输出

大家可以看到,无论是动态库还是静态库中,都包含了一个 8字节大小的 a_inst 符号(至于为什么是在 .bss段,大家可以参考《程序员的自我修养》7.3.4 共享模块的全局变量问题 章节);但是在两个可执行文件中都不包含 a_inst 符号,于是,根据可执行文件对于库的使用方式,就会有以下行为:

粗略地理解,就是和上面所说的那样。当然,这其中还涉及到很多的原理和细节,无法一一展开,读者有兴趣的话可以自行查阅相关的知识点。

OK ~ 在梳理出基本的原因之后,有没有办法来解决这个问题呢?有,这里提供2个思路。

按照思路1,我们来增加一个 test_a_inst.cc 来验证这个思路,代码如下:

#include "liba/func.h"
#include "libb/func.h"
#include <iostream>

class ACls;
extern ACls* a_inst; // 声明外部符号

int main(int argc, char* argv[]) {
  std::cout << "a_inst: " << a_inst << std::endl;  // 这里使用了外部符号 a_inst
  std::cout << "AddFuncA(2, 3): " << AddFuncA(2, 3) << std::endl;
  std::cout << "AddFuncB(2.0f, 3.0f): " << AddFuncB(2.0f, 3.0f) << std::endl; 
}

在CMakeLists.txt增加新的案例:

add_executable(test_a_inst_static ${PROJECT_SOURCE_DIR}/test_a_inst.cc)
add_executable(test_a_inst_shared ${PROJECT_SOURCE_DIR}/test_a_inst.cc)
target_link_libraries(test_a_inst_static addfunc_static)
target_link_libraries(test_a_inst_shared addfunc_shared)

编译完成后,我们运行一下可执行文件:

root@b99fab697c9e:build# ./test_a_inst_static
Create an ACls instance and do some things!  # 成功初始化了 a_inst !!!
a_inst: 0x5599fcb3deb0
AddFuncA(2, 3): 5
AddFuncB(2.0f, 3.0f): 5
root@b99fab697c9e:build# ./test_a_inst_shared
Create an ACls instance and do some things!
a_inst: 0x55e3287aaeb0
AddFuncA(2, 3): 5
AddFuncB(2.0f, 3.0f): 5

可以看到,虽然test_a_inst_static是链接了libaddfunc_static.a静态库,但由于它引用了a_inst,于是a_inst被成功初始化了!通过objdump进行分析,我们会发现,test_a_inst_static的段表中,确实包含了 a_inst 符号。这说明,它被ld链接器认为是一个必须的符号,从而合并到了可执行文件中。

root@b99fab697c9e:build# objdump -t test_a_inst_static | grep a_inst
test_a_inst_static:     file format elf64-x86-64
0000000000000000 l    df *ABS*        0000000000000000              test_a_inst.cc
0000000000001463 l     F .text        0000000000000019              _GLOBAL__sub_I_a_inst
0000000000004158 g     O .bss        0000000000000008              a_inst

接下来,我们继续来尝试下思路2,直接修改原来test_static可执行文件的链接属性。在CMakeLists.txt中增加一行代码:

target_link_libraries(test_static addfunc_static)
set_target_properties(test_static PROPERTIES LINK_FLAGS 
                      "-Wl,--whole-archive libaddfunc_static.a -Wl,-no-whole-archive")  # 增加这行

’-Wl,--whole-archive libaddfunc_static.a’ 的含义是,将 libaddfunc_static.a 整个库链接到 可执行文件中,后面的 ‘-Wl,-no-whole-archive’ 是指取消 whole-archive 的链接方式,这是必须要加的,否则所有的库都会按照 whole-archive进行链接,加了这后半句后,就只会对 libaddfunc_static.a 采用whole-archive 的链接方式。

编译后,直接运行下 test_static ,结果如下:

root@b99fab697c9e:build# ./test_static
Create an ACls instance and do some things!  # 已经成功初始化 a_inst !
AddFuncA(2, 3): 5
AddFuncB(2.0f, 3.0f): 5
# 查看符号信息
objdump -t test_static | grep a_inst
000000000000130c l     F .text        0000000000000019              _GLOBAL__sub_I_a_inst
0000000000004158 g     O .bss        0000000000000008              a_inst

成功了!a_inst 已经被初始化,并且 objdump 输出的结果也表明 a_inst 这个符号存在于可执行文件 test_static中。呼 ~ 又长呼一口气,这也是个不太好回答的问题,所幸总算梳理了一些基本的思路。

0x0b 问题9:如何处理使用静态库时遇到的符号重定义错误?

设想这样一个场景,我们需要编译一个动态库或可执行文件,它需要链接不同的静态库,libnna.a和libnnb.a,但是这两个静态库里面又恰好都包含了libonnx.a(这种情况还是会遇到的,比如两个不同的推理引擎的静态库,都用到了onnx,于是他们在打包静态库的时候都把libonnx.a都打包进推理引擎的静态库中,而业务可能这两个推理引擎都需要用到),那么在链接阶段,就会出现符号重定义的错误。这种时候该怎么解决这个问题呢?如果你确信重定义的符号,他们的实现是完全相同的,那么可以参考以下的解决方式。对于gcc,可以添加"-Wl,-allow-multiple-definition"来允许重复符号的存在,在链接的时候,ld链接器只会使用遇到的该符号的第一个定义;而对于MSVC则可以添加“/FORCE:MULTIPLE”。最近也正好在折腾OpenBLAS,翻了翻它的CMakeLists.txt源码,可以看到它就是通过这种方式来处理符号重定义问题的。

if (NOT MSVC)
    target_link_libraries(${OpenBLAS_LIBNAME}_shared "-Wl,-allow-multiple-definition")
else()
    set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} /FORCE:MULTIPLE")
endif()

0x0c 问题10:如何优雅地编译一个静态库?以onnxruntime为例

**TODO:**这个onnxruntime的例子暂时还没写完,大家想看的话,我后面再找时间补上吧~

关于这个问题,我想用一个更加实用的例子来回答而不再是停留在本文一开始的小案例(它毕竟只有个加法函数而已),比如,如何编译一个可用的onnxruntime Android静态库。由onnxruntime最近的release看到,它官方并没有发布Android版本的静态库,但有些时候,我们可能就是想使用静态库而不是动态库,那么不妨就以onnxruntime为例,我们来尝试一下能不能编译一个可用的Android版本的静态库。(提示:Android 交叉编译、NDK怎么配置这些本文不会提及,大家可以自行查阅相关知识,本文只关注静态链接和静态库。)

老规矩,在进行具体的实践前,我们先分析思路。在此之前,我们已经分析了9个关于静态链接和静态库的问题,基于对这些问题的分析结果,我们其实可以很快地就形成了2种方案:

首先,关于方案1,我们在交叉编译完onnxruntime的动态库后,会在CMakeFiles中发现有一个onnxruntime.dir,这里有一个link.txt,没错,又是它!通过查看这个文件,我们可以知道生成onnxruntime的动态库,都需要依赖哪些库。

cat link.txt  # 省略了一些内容
/Users/xxx/Library/Android/sdk/ndk/25.1.8937393/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang++ 
 -shared -Wl,-soname,libonnxruntime.so -o libonnxruntime.so CMakeFiles/onnxruntime.dir/generated_source.c.o  \
libonnxruntime_session.a libonnxruntime_optimizer.a libonnxruntime_providers.a \
libonnxruntime_framework.a libonnxruntime_graph.a libonnxruntime_util.a \
libonnxruntime_mlas.a libonnxruntime_common.a libonnxruntime_flatbuffers.a \
external/onnx/libonnx.a external/onnx/libonnx_proto.a external/protobuf/cmake/libprotobuf-lite.a \
external/re2/libre2.a external/abseil-cpp/absl/base/libabsl_base.a \
external/abseil-cpp/absl/base/libabsl_throw_delegate.a \
external/abseil-cpp/absl/container/libabsl_raw_hash_set.a \
external/abseil-cpp/absl/hash/libabsl_hash.a \
external/abseil-cpp/absl/hash/libabsl_city.a \
external/abseil-cpp/absl/hash/libabsl_low_level_hash.a \
external/abseil-cpp/absl/base/libabsl_raw_logging_internal.a \
external/flatbuffers/libflatbuffers.a external/pytorch_cpuinfo/libcpuinfo.a \
external/pytorch_cpuinfo/deps/clog/libclog.a -llog external/nsync/libnsync_cpp.a\
 -ldl external/abseil-cpp/absl/hash/libabsl_hash.a external/abseil-cpp/absl/hash/libabsl_city.a\
external/abseil-cpp/absl/hash/libabsl_low_level_hash.a \
external/abseil-cpp/absl/types/libabsl_bad_variant_access.a \
external/abseil-cpp/absl/strings/libabsl_cord.a \
external/abseil-cpp/absl/strings/libabsl_cordz_info.a \
external/abseil-cpp/absl/strings/libabsl_cord_internal.a \
external/abseil-cpp/absl/strings/libabsl_cordz_functions.a \
external/abseil-cpp/absl/strings/libabsl_cordz_handle.a \
external/abseil-cpp/absl/container/libabsl_raw_hash_set.a \
external/abseil-cpp/absl/types/libabsl_bad_optional_access.a \
external/abseil-cpp/absl/container/libabsl_hashtablez_sampler.a \
external/abseil-cpp/absl/profiling/libabsl_exponential_biased.a \
external/abseil-cpp/absl/synchronization/libabsl_synchronization.a \
external/abseil-cpp/absl/synchronization/libabsl_graphcycles_internal.a \
external/abseil-cpp/absl/debugging/libabsl_stacktrace.a \
external/abseil-cpp/absl/debugging/libabsl_symbolize.a \
external/abseil-cpp/absl/base/libabsl_malloc_internal.a \
external/abseil-cpp/absl/debugging/libabsl_debugging_internal.a \
external/abseil-cpp/absl/debugging/libabsl_demangle_internal.a \
external/abseil-cpp/absl/time/libabsl_time.a external/abseil-cpp/absl/strings/libabsl_strings.a \
external/abseil-cpp/absl/base/libabsl_throw_delegate.a \
external/abseil-cpp/absl/numeric/libabsl_int128.a \
external/abseil-cpp/absl/strings/libabsl_strings_internal.a \
external/abseil-cpp/absl/base/libabsl_base.a \
external/abseil-cpp/absl/base/libabsl_raw_logging_internal.a \
-pthread external/abseil-cpp/absl/base/libabsl_log_severity.a \
external/abseil-cpp/absl/base/libabsl_spinlock_wait.a \
external/abseil-cpp/absl/time/libabsl_civil_time.a \
external/abseil-cpp/absl/time/libabsl_time_zone.a -latomic -lm

可以看到onnxruntime依赖了非常多的三方库,要是没有这个link.txt,我们要自己去分析这些依赖,那简直太痛苦了。在知道onnxruntime所需的所有的.a后,我们就可以写一个 .mri 脚本来合并这些库(使用ar合并静态库的另一种方式,可以参考: 很酷的程序员:CMake应用:合并静态库的最佳实践

create libonnxruntime_static.a
addlib libonnxruntime_session.a 
addlib libonnxruntime_optimizer.a 
addlib libonnxruntime_providers.a
addlib libonnxruntime_framework.a 
addlib libonnxruntime_graph.a 
addlib libonnxruntime_util.a 
addlib libonnxruntime_mlas.a 
addlib libonnxruntime_common.a 
addlib libonnxruntime_flatbuffers.a
addlib external/onnx/libonnx.a external/onnx/libonnx_proto.a 
addlib external/protobuf/cmake/libprotobuf-lite.a
// ... 还有很多其他的库
save
end
$ANDROID_NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar -M < merge.mri

对于方案2,按照思路,需要给onnxruntime的cmake文件打个补丁,从onnxruntime动态库target中获取依赖库信息,然后将其中的静态库合并成libonnxruntime_static.a,整个过程都在cmake中完成,在编译onnxruntime动态库后,自动生成一个静态库。这种方式我还没有完全实现,但是,这种做法是有参考的,比如Paddle Lite中对于静态库就是这么处理的,完整的逻辑可以参考: Paddle-Lite/lite.cmake at develop · PaddlePaddle/Paddle-Lite ,这里我只摘其中核心的逻辑和大家一睹为快。

function(bundle_static_library tgt_name bundled_tgt_name fake_target)
  list(APPEND static_libs ${tgt_name})
  add_dependencies(lite_compile_deps ${fake_target})
  # 递归地获取所有依赖库 LINK_LIBRARIES 和 INTERFACE_LINK_LIBRARIES 都是 cmake 中 target 默认支持的属性
  function(_recursively_collect_dependencies input_target)
    set(_input_link_libraries LINK_LIBRARIES) # LINK_LIBRARIES是直接依赖库
    get_target_property(_input_type ${input_target} TYPE)
    if (${_input_type} STREQUAL "INTERFACE_LIBRARY")
      set(_input_link_libraries INTERFACE_LINK_LIBRARIES) # INTERFACE_LINK_LIBRARIES是间接依赖库
    endif()
    get_target_property(public_dependencies ${input_target} ${_input_link_libraries})
    foreach(dependency IN LISTS public_dependencies)
      if(TARGET ${dependency})  # 对库的别名进行处理
        get_target_property(alias ${dependency} ALIASED_TARGET)
        if (TARGET ${alias})
          set(dependency ${alias})
        endif()
        get_target_property(_type ${dependency} TYPE)
        # 并且判断是不是静态库
        if (${_type} STREQUAL "STATIC_LIBRARY")
          list(APPEND static_libs ${dependency})
        endif()
        get_property(library_already_added
          GLOBAL PROPERTY _${tgt_name}_static_bundle_${dependency})
        if (NOT library_already_added)  # 对遍历过的库进行标记,避免无限递归
          set_property(GLOBAL PROPERTY _${tgt_name}_static_bundle_${dependency} ON)
          _recursively_collect_dependencies(${dependency})
        endif()
      endif()
    endforeach()
    set(static_libs ${static_libs} PARENT_SCOPE)
  endfunction()
  _recursively_collect_dependencies(${tgt_name})
  list(REMOVE_DUPLICATES static_libs) # 删除重复的库 这是由于如果存在重复的库,ar工具合并时也会重复
  set(bundled_tgt_full_name
    ${PADDLE_BINARY_DIR}/${CMAKE_STATIC_LIBRARY_PREFIX}${bundled_tgt_name}${CMAKE_STATIC_LIBRARY_SUFFIX})
  message(STATUS "bundled_tgt_full_name:  ${PADDLE_BINARY_DIR}/${CMAKE_STATIC_LIBRARY_PREFIX}${bundled_tgt_name}${CMAKE_STATIC_LIBRARY_SUFFIX}")
  # 省略一些代码 ...
  add_custom_target(${fake_target})
  add_dependencies(${fake_target} ${tgt_name})
  # 不同OS下调用的静态库处理工具不一样
  if(NOT IOS AND NOT APPLE)
    # Linux下的处理 
    file(WRITE ${PADDLE_BINARY_DIR}/${bundled_tgt_name}.ar.in # 生成合并脚本 类似 merge.mri
      "CREATE ${bundled_tgt_full_name}\n" )
    # 省略一些代码 ...
    add_custom_command(
      TARGET ${fake_target} PRE_BUILD
      COMMAND rm -f ${bundled_tgt_full_name}
      COMMAND ${ar_tool} -M < ${PADDLE_BINARY_DIR}/${bundled_tgt_name}.ar  # 调用 ar 合并静态库
      COMMENT "Bundling ${bundled_tgt_name}"
      DEPENDS ${tgt_name}
      VERBATIM)
  else()
    # ...
  endif()
  # ...
endfunction()

上面这个自定义的cmake函数,实现的正是从一个动态库中读取它的所有依赖库信息。我们来看下它的用法:

bundle_static_library(paddle_api_full paddle_api_full_bundled bundle_full_api)

nice ~ 巧妙地利用动态库的特性和cmake target 的属性来完成了静态库的合并 ~

0x0d 总结

到这里,本文就讲的差不多了,希望不会过于复杂难懂,也希望各位读者看完后能觉得开卷有益。本文以一个实际的案例为基础线,承上启下地讲述了我在实践过程中遇到的10个问题,并给出了对应的理解或解决方案。文中的内容,基本都是本垃圾踩过的坑,记录下来,希望每一个坑都不要白踩~ 最后,祝大家2023年,新年快乐 ~ (最后,遗留了一些TODO,以后有时间再补上吧)

最后,附上案例代码:

0x0e 参考文献

编辑于 2023-01-09 23:25 ・IP 属地广东

请 Ta 喝咖啡 ☕️